Day 21 已經讓 PostgreSQL 的 WAL 能從主節點傳到複本節點,多台主機擁有保存資料副本的能力,接下來還要讓它們可以知道誰可以接受寫入。當程序、主機或網路發生故障時,叢集還需要一個所有節點都能依循的一致決策來源。
今天建立分散式設定儲存(Distributed Configuration Store,DCS),先理解 etcd 如何用 Raft、多數決與租約保存少量協調狀態,再以三成員叢集實際驗證:保留兩票時可以提交更新,只剩一票時則停止形成新的決策。
本文閱讀方式
- 完整理解技術與底層原理:依序閱讀全文。
- 掌握完成部署所需的基本概念:優先閱讀標有「⭐」的章節。
- 快速了解當天的部署內容:閱讀文末的 Lab/實作章節。
- 跟著系列建立完整系統:依照 GitHub 詳細部署文件 的天數順序操作。
etcd 簡單來說,是讓多台主機共同使用的鍵值對儲存服務。Key 用來標示狀態名稱與用途,對應的值則保存實際內容。例如 Patroni 可以使用下列鍵值對協調 PostgreSQL 叢集:
| Key | 值的簡化範例 | 表示的狀態 |
|---|---|---|
/service/iron-pg/leader |
pg01 |
pg01 目前持有領導鎖 |
/service/iron-pg/config |
ttl: 30 |
整個叢集共同使用的設定 |
每次建立、修改或刪除鍵值對時,etcd 都會產生遞增的修訂版本(Revision),讓其他程式辨認狀態變更的先後順序。etcd 專門保存這類少量協調資料。PostgreSQL 的資料表、索引與 WAL 由資料庫節點保存。
前一天已提到,PostgreSQL 的串流複寫(Streaming Replication)負責將 WAL 從目前的主節點傳給複本節點,讓多台資料庫保有可以接續使用的資料副本。安全選出新主節點還需要一致的決策依據。若網路分割後兩側各自提升一台資料庫接受寫入,就可能形成腦裂。
在完整的高可用性架構中,Patroni 會檢查各節點的 PostgreSQL 狀態,並透過 etcd 保存成員資訊、共用設定與具有期限的領導鎖(Leader Lock)。在本系列未啟用 DCS Failsafe Mode 的設定下,目前的可寫節點必須持續更新領導鎖。需要接手的節點,也必須先透過 etcd 的多數決取得這項權利,才可以由 Patroni 執行提升。etcd 提供所有節點共同承認的決策依據。PostgreSQL 資料新舊判斷及角色提升、降級由 Patroni 負責。
因此,PostgreSQL 保存真正的資料並傳送 WAL,SQL 查詢與 WAL 始終留在資料庫路徑。etcd 只保存少量協調狀態。單成員 etcd 沒有故障容忍。該成員失效後,控制程序便無法安全更新領導鎖。三成員 etcd 叢集可在一個成員故障時保留多數決,並在網路分割時只允許擁有多數的一側形成新決策。
今天先建立並驗證 etcd 這個一致決策基礎。下一篇再導入 Patroni,讓它利用 etcd 保存的協調狀態管理 PostgreSQL 的節點角色。
單一主機可以直接讀取本機狀態,多台主機卻可能因封包延遲、單向中斷、程序暫停或網路分割(Network Partition)看到不同結果。此時,Ping 失敗只表示這條探測路徑沒有收到回覆。節點須透過多數決判斷自己位於多數側或少數側,再決定是否具有改變角色的權利。
共識(Consensus)的目的,是讓分散式成員對操作順序與已提交狀態形成一致結論。失去多數的一側會停止提交新決策,以保護既有的一致性。
前面介紹 PVE 叢集時,也用過法定票數避免兩側各自修改共享狀態。兩者都把程序是否執行與目前是否有權修改共享狀態分開,但保護的對象與形成決策的方式不同:
| 比較項目 | PVE 叢集 | etcd 叢集 |
|---|---|---|
| 形成決策 | Corosync 更新成員狀態,再由 votequorum 計算法定票數 | Raft 領導者複寫日誌,多數成員確認已寫入後標記為已提交 |
| 三成員門檻 | 三票中至少保留兩票 | 三個具投票權成員中至少保留兩個 |
| 失去多數 | 限制 pmxcfs 寫入、叢集管理與 HA 決策。已執行的 VM 不一定立即停止 | 無法提交新的鍵值對更新及依賴共識的操作。etcd 程序不一定停止 |
| 保護對象 | PVE 叢集設定與 HA 決策 | DCS 的鍵值對、修訂版本與租約狀態 |
PVE 的第一台建置節點加入後就和其他節點一樣,etcd 則會在每個任期選出一位 Raft 領導者,兩者的節點角色與選舉方式不同。Ceph MON 使用另一套獨立的法定票數機制。PVE 的完整機制可回到 Day 07 的投票與法定票數 複習。下文則繼續觀察 etcd 如何提交一筆設定更新。
etcd 使用 Raft 共識演算法。每個具投票權的成員(Member)在同一個任期(Term)中會扮演領導者(Leader)、跟隨者(Follower)或候選者(Candidate):
| 角色 | 主要工作 |
|---|---|
| 領導者 | 接收並協調需要共識的更新,將日誌複寫給其他成員 |
| 跟隨者 | 接收領導者的心跳與日誌,並回覆複寫進度 |
| 候選者 | 在選舉逾時後發起新任期的選舉 |
一筆寫入必須取得多數成員確認才會生效。領導者先將提案(Proposal)加入自己的 Raft 日誌,再複寫到其他具投票權的成員。確認多數成員已寫入相同內容後,領導者才將該日誌項目(Log Entry)標記為已提交(Committed),之後再套用到 etcd 的鍵值對儲存。

圖(一)etcd 領導者將更新寫入 Raft 日誌,多數成員確認已寫入後,才將日誌標記為已提交、套用變更並回覆用戶端。
圖中為了呈現 Raft 的提交順序,直接將請求畫向領導者。實際上,用戶端可以連線到任一健康的 etcd 端點。需要共識的請求若抵達跟隨者,會由該成員轉送給領導者處理,因此用戶端應設定多個端點,確保單一入口失效後可以存取健康的叢集。
任期用來區分不同輪次的選舉。日誌索引表示項目在 Raft 日誌中的位置。修訂版本則是鍵值對儲存的邏輯版本,每次修改都會遞增,讓用戶端判斷事件先後。三者用途不同。
Raft 領導者負責協調 etcd 日誌。PostgreSQL 主節點負責接受資料庫寫入。Patroni 的領導鎖則代表某個節點持有資料庫主角色的權利。三種角色名稱相近,實際屬於不同元件。
具投票權成員數量為 N 時,法定票數(Quorum)為:
floor(N / 2) + 1
| 具投票權成員 | 法定票數 | 可容忍同時故障數 |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 2 | 1 |
| 4 | 3 | 1 |
| 5 | 3 | 2 |
四個成員與三個成員都只能容忍一個故障,因此 etcd 通常採用奇數個具投票權成員。成員數應依容錯需求規劃,因為每增加一個成員也會增加複寫與維護成本。
三成員叢集的實際行為如下:
| 可互相形成叢集的成員 | 結果 | 原因 |
|---|---|---|
| 3/3 | 可以提交更新 | 三票中至少取得兩票 |
| 2/3 | 可以提交更新 | 保有法定票數 |
| 1/3 | 停止提交更新 | 一票不足以形成原本三成員叢集的多數 |
程序顯示 active (running),只證明程序維持運作。網路分割時,擁有 2/3 的一側可以維持一致決策。只有 1/3 的一側則會停止提交更新,避免建立另一套互相衝突的狀態。
這就是 etcd 在共識層提供的隔離機制:少數側的程序可以繼續執行,但會失去形成新決策的權力,避免網路分割後兩側各自建立互相衝突的叢集狀態。
etcdctl 預設的線性一致讀取同樣需要叢集確認。失去多數後,即使某個成員本機還留著舊資料,也不應把可能過期的本機內容解讀成目前叢集狀態。
除了基本的鍵值對讀寫,etcd 還提供監看(Watch)、交易(Transaction)與租約(Lease)等能力。
每個 Key 除了名稱與值,也帶有建立版本、修改版本、更新次數與所屬租約等中繼資料。監看可以從指定修訂版本持續接收後續事件。交易則能先比較版本或值,再決定要執行哪一組操作。這些能力讓多個控制程序能在同一份狀態上做有條件的更新,避免彼此覆蓋。
PostgreSQL 的資料表、索引與 WAL 保存在 PostgreSQL 節點,etcd 不在 SQL 資料路徑中:
| 元件 | 主要責任 | 不負責的工作 |
|---|---|---|
| PostgreSQL | 資料表、索引、交易狀態與 WAL | 跨節點的一致角色協調 |
| etcd | 成員資訊、設定、鎖、租約與狀態版本 | 保存資料表或傳送 PostgreSQL WAL |
| Patroni | 讀取本機資料庫與 DCS 狀態後管理角色 | 取代 PostgreSQL 傳送 WAL |
因此,etcd 叢集失去法定票數後,控制程序會失去安全更新叢集狀態的依據,但既有的 PostgreSQL 資料檔保存在資料庫節點。三份 PostgreSQL 資料負責資料備援,一致的角色選擇則需要 etcd 提供決策依據。
etcd 對外提供兩種目的不同的連線。TCP 2379 是用戶端端點,供 etcdctl、Patroni 與其他受控程式呼叫鍵值對、監看、交易與租約 API。TCP 2380 是成員對等端點,供 etcd 成員交換 Raft 心跳、選舉與日誌複寫訊息。

圖(二)TCP 2379 承載用戶端 API,TCP 2380 承載 etcd 成員之間的 Raft 通訊。
設定檔中的 Listen 與 Advertise 也回答不同問題:
| 設定 | 作用 |
|---|---|
| Listen Client URLs | 本機在哪些位址監聽 TCP 2379 |
| Advertise Client URLs | 告訴用戶端應使用哪個位址連線 |
| Listen Peer URLs | 本機在哪些位址監聽 TCP 2380 |
| Initial Advertise Peer URLs | 告訴其他成員應使用哪個位址連線 |
| Initial Cluster | 第一次啟動時使用的成員名稱與 Peer URL 清單 |
Advertise 位址必須能從真正的連線來源抵達,遠端成員應使用可路由的位址,而非只在本機有效的 127.0.0.1。三台第一次建立叢集時,Initial Cluster 清單與 Token 必須一致,而 Name 與各自的 Listen/Advertise 位址必須唯一。叢集建立完成後,成員身分會保存在資料目錄。日後增加或替換成員應使用正式的成員變更流程,第一次啟動設定只負責建立新叢集。
Debian 套件可從 /etc/default/etcd 讀取環境變數。以下以 etcd01 為例,只保留成員身分、兩條通訊路徑、初始叢集與雙向 TLS 所需欄位。etcd02、etcd03 使用相同的初始叢集清單與 Token,並將名稱及本機位址分別改成自己的值。
ETCD_NAME="etcd01"
ETCD_DATA_DIR="/var/lib/etcd/iron-pg"
ETCD_LISTEN_CLIENT_URLS="https://10.77.30.11:2379,https://127.0.0.1:2379"
ETCD_ADVERTISE_CLIENT_URLS="https://10.77.30.11:2379"
ETCD_LISTEN_PEER_URLS="https://10.77.30.11:2380"
ETCD_INITIAL_ADVERTISE_PEER_URLS="https://10.77.30.11:2380"
ETCD_INITIAL_CLUSTER="etcd01=https://10.77.30.11:2380,etcd02=https://10.77.30.12:2380,etcd03=https://10.77.30.13:2380"
ETCD_INITIAL_CLUSTER_STATE="new"
ETCD_INITIAL_CLUSTER_TOKEN="iron-pg-etcd-v1"
ETCD_CERT_FILE="/etc/etcd/tls/node.crt"
ETCD_KEY_FILE="/etc/etcd/tls/node.key"
ETCD_TRUSTED_CA_FILE="/etc/etcd/tls/ca.crt"
ETCD_CLIENT_CERT_AUTH="true"
ETCD_PEER_CERT_FILE="/etc/etcd/tls/node.crt"
ETCD_PEER_KEY_FILE="/etc/etcd/tls/node.key"
ETCD_PEER_TRUSTED_CA_FILE="/etc/etcd/tls/ca.crt"
ETCD_PEER_CLIENT_CERT_AUTH="true"
前面已介紹 TLS 與雙向 TLS 的作用。在 etcd 中,相同機制會同時套用到 TCP 2379 的用戶端連線與 TCP 2380 的對等端連線,連線雙方都要使用受信任 CA 簽發的憑證驗證身分。
| 路徑 | 需要確認的身分 | 防火牆範圍 |
|---|---|---|
| 用戶端,TCP 2379 | etcd 伺服器與獲准的用戶端 | PostgreSQL/Patroni 節點及受控管理來源 |
| 對等端,TCP 2380 | 參與 Raft 的 etcd 成員 | 僅 etcd 成員彼此互連 |
憑證的主體別名(Subject Alternative Name,SAN)必須包含實際使用的 DNS 名稱或 IP,私鑰則留在使用該身分的主機。mTLS 負責連線身分與傳輸保護。目前 Lab 以受信任 CA 簽發的用戶端憑證作為連線門檻。正式環境可再啟用 etcd authentication,使用獨立憑證、使用者與最小權限角色限制 API 操作。
時間同步應納入基線,因為憑證有效期間、租約觀察、監控與跨節點紀錄都依賴可比較的時間。成員能否形成多數則取決於 Raft 通訊與投票狀態。
租約具有存活時間(Time to Live,TTL),一個或多個 Key 可以附著在租約上。用戶端持續傳送 KeepAlive 時,租約會延長。用戶端停止傳送 KeepAlive 並超過 TTL 後,etcd 會撤銷租約並刪除附著的 Key。
程式只在保存程序存活期間才有效的資料時主動申請租約,並在寫入 Key 時指定該租約。一般沒有附著租約的 Key 會持續存在,直到程式明確修改或刪除。租約負責存活偵測與自動到期。資料庫交易隔離與失聯程序的動作控制由上層程式負責。上層程式必須遵守鎖的結果,並在無法傳送 KeepAlive 時停止受保護的動作。

圖(三)以 Patroni 領導鎖為例:建立具有期限的領導鎖時使用租約並綁定 Key。正常時持續傳送 KeepAlive,失聯後則由 etcd 在 TTL 歸零時刪除 Key。
後續控制程序會利用這項能力維護具有期限的領導鎖:持有者必須持續更新租約,其他節點才能判斷這項權利是否有效。etcd 負責一致保存鎖與租約。上層控制程序負責檢查 PostgreSQL 的 WAL 進度、判斷候選條件及執行角色變更。
用戶端遇到逾時時,也要保留一個重要觀念:逾時只表示用戶端沒有在期限內取得明確回覆,操作可能已提交,也可能尚未提交。恢復法定票數後,應重新讀取該 Key 或使用可重試、可辨識的操作確認最終狀態,避免不確定期間重複執行有副作用的動作。
微服務(Microservices)與雲端原生(Cloud Native)架構興起後,系統經常由多個可動態建立、搬移與失效的服務執行個體組成。控制程序需要共同讀取服務位置、設定版本與資源持有權等中繼資料,並在多台主機之間維持一致的狀態,因此 etcd 這類具備強一致性的鍵值儲存,廣泛應用於分散式系統與 Kubernetes 等平台。
常見用途包括:
Kubernetes 是最具代表性的例子。API Server 會將 API 物件與控制平面需要的叢集狀態保存到 etcd,控制器與排程器則透過 API Server 監看物件變化,推動後續調度及狀態校正。這些用途的共同點,是保存需要嚴格順序的少量中繼資料。大量業務資料、檔案、WAL 與訊息佇列則交給對應的資料系統處理。
本次憑證私鑰只允許 etcd 服務帳號讀取,因此先在目前的 Bash 工作階段建立 etcdctl_tls 函式,集中帶入三個端點與 mTLS 憑證,再執行日常唯讀檢查:
etcdctl_tls() {
sudo -u etcd env \
ETCDCTL_API=3 \
ETCDCTL_ENDPOINTS='https://10.77.30.11:2379,https://10.77.30.12:2379,https://10.77.30.13:2379' \
ETCDCTL_CACERT='/etc/etcd/tls/ca.crt' \
ETCDCTL_CERT='/etc/etcd/tls/node.crt' \
ETCDCTL_KEY='/etc/etcd/tls/node.key' \
etcdctl "$@"
}
# 查看成員名冊
etcdctl_tls member list -w table
# 比較三個端點的版本、Raft 任期、索引與領導者
etcdctl_tls endpoint status --cluster -w table
# 確認三個端點能否完成需要共識的健康檢查
etcdctl_tls endpoint health --cluster
# 查看指定前綴下的鍵值對
etcdctl_tls get /lab/day22/ --prefix
# 查看目前存在的租約與叢集警報
etcdctl_tls lease list
etcdctl_tls alarm list
# 查看本機 etcd 最近的服務紀錄
sudo journalctl -u etcd -n 100 --no-pager
member list 回答叢集登記了哪些成員,endpoint status 顯示每個端點目前觀察到的 Raft 狀態,endpoint health 則實際提交提案來確認端點能否參與一致決策。三項結果回答的問題不同,健康檢查時應一起判讀。
本次將 etcd01、etcd02、etcd03 分別部署在 pg01、pg02、pg03,使用 Day 19 建立的內部 CA 簽發節點憑證,並同時保護 TCP 2379 用戶端連線與 TCP 2380 對等端連線。完整安裝、憑證申請、設定檔與故障操作放在文件庫,正文只保留本日要驗證的關係。
GitHub 實作文件:Day 22|建立三節點 etcd
member list、endpoint status 與 endpoint health 確認叢集狀態。三台主機的 ETCD_INITIAL_CLUSTER 與 ETCD_INITIAL_CLUSTER_TOKEN 相同,ETCD_NAME、用戶端 URL 與對等端 URL 則分別對應 10.77.30.11、10.77.30.12、10.77.30.13。每張節點憑證包含自己的 DNS 名稱、IP、伺服器驗證(Server Authentication)與用戶端驗證(Client Authentication)用途,私鑰權限限制為 etcd 服務帳號可讀。

圖(四)pg01 的 etcd 憑證包含節點名稱、IP、伺服器與用戶端驗證用途,私鑰權限則限制為 etcd 服務帳號可讀。
三個端點全部啟動後,member list 應列出三個成員。endpoint health 應顯示三個端點皆可成功提交提案。endpoint status -w table 的 IS LEADER 則只會有一列為 true。確認整個叢集正常需要同時檢查成員名冊、所有端點的健康狀態與目前領導者。

圖(五)三個成員均已啟動,三個端點可以提交提案,且當下只有 etcd03 為 Raft 領導者。
先確認目前的領導者,停止一個非領導者成員,再對剩餘端點寫入具辨識度的測試 Key 並立即讀回。兩個成員達到三票中的法定票數,因此寫入成功。此時只有被停止的端點失效,另外兩個成員可以繼續形成多數。


圖(六)停止非領導者 etcd02 後,叢集保有 2/3 法定票數,因此可以提交並讀回 /lab/day22/two-of-three。
保留 pg01 的 etcd 程序,再停止另外兩個成員。此時 pg01 的程序可以維持 active,但新的線性一致讀取、寫入與租約更新無法取得多數確認,操作應逾時或回報沒有可用的領導者。這證明程序存活與叢集可提交更新是兩件不同的事。
失去多數期間若 put 回傳逾時,要先把結果記為不確定。恢復第二票後重新讀取該 Key,才能判斷它是否在逾時前已提交。

圖(七)只剩 1/3 成員時,pg01 的 etcd 程序維持 active,但新的 put 因無法取得多數確認而逾時。
在三個成員恢復健康後,建立一個短 TTL 租約,將 /lab/day22/lease-demo 綁定到該租約,但不執行 KeepAlive。先讀取到 Key,再於 TTL 到期後重新查詢。第二次查詢為空,即可把租約到期與 Key 移除串成同一份證據。

圖(八)將測試 Key 附著於 15 秒租約後,租約有效期間可以讀取該 Key。未執行 KeepAlive 並超過 TTL 後,Key 會由 etcd 自動移除。
依序恢復 pg02、pg03,再次執行三項叢集檢查。最終結果應包含三個成員、三個健康端點、單一 Raft 領導者,並能讀取先前已提交的測試 Key。這是本次故障實驗的恢復條件,不以單一服務顯示 running 作為完成標準。

圖(九)恢復 pg02 與 pg03 後,三個端點重新健康並形成單一 Raft 領導者,故障前及 2/3 狀態下提交的資料可以讀取。
| 要回答的問題 | 證據 | 判讀方式 |
|---|---|---|
| 三個成員是否屬於同一叢集 | member list |
三個唯一的成員 ID、名稱與對等端 URL 同時存在 |
| 用戶端與對等端身分是否符合本次規格 | 憑證 SAN、用途、檔案權限與 etcd 設定 | 位址、用途與實際監聽/宣告 URL 一致 |
| 目前是否只有一個 Raft 領導者 | endpoint status -w table |
三列中只有一列 IS LEADER=true |
| 2/3 是否可以形成法定票數 | 停止一個非領導者後執行 put/get |
新值可以提交並讀回 |
| 1/3 是否停止形成新決策 | 兩個成員停止後的服務狀態與 put 結果 |
程序維持執行,但操作無法取得多數確認 |
| 租約是否具有期限 | 建立租約、綁定前後與到期後執行 get |
未執行 KeepAlive 時,附著的 Key 在 TTL 到期後消失 |
| 叢集是否完整恢復 | member list、endpoint health、endpoint status |
三個端點健康且只有一個領導者 |
下一篇是 Day 23|PostgreSQL HA 叢集實作(上):使用 Patroni 完成自動選主與狀態管理。我們會在三台 PostgreSQL 節點導入 Patroni,將串流複寫與今天建立的 etcd 決策基礎連接起來,並建立一台 Primary、兩台 Replica 的高可用性叢集,確認 PostgreSQL 的角色與生命週期已由 Patroni 統一管理。